@starci/hfs 4.5.0 → 4.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/ci/cli.mjs +6 -4
  3. package/ci/vars.mjs +22 -5
  4. package/ci/verify.mjs +16 -2
  5. package/lint/run.mjs +3 -4
  6. package/package.json +1 -1
  7. package/release/ci-red.mjs +44 -12
  8. package/release/gate.mjs +50 -19
  9. package/release/push.mjs +1 -1
  10. package/runtime/knowledge/hfs/canon-pins.yaml +5 -5
  11. package/runtime/knowledge/sonar-gate.yaml +2 -2
  12. package/runtime/modules/schemas/application-stacks.schema.yaml +35 -0
  13. package/runtime/packages/cli/src/catalog.generated.mjs +9 -7
  14. package/runtime/scripts/hfs/ci-declaration.mjs +12 -3
  15. package/runtime/scripts/hfs/rules/secrets.mjs +8 -10
  16. package/runtime/scripts/lib/secret-patterns.mjs +77 -0
  17. package/scaffold/app.mjs +2 -2
  18. package/sync/ci-commands.mjs +154 -14
  19. package/sync/hygiene.mjs +4 -1
  20. package/sync/index.mjs +16 -12
  21. package/sync/quality-services.mjs +7 -5
  22. package/sync/readme-badges.mjs +18 -2
  23. package/sync/stack-images.mjs +5 -0
  24. package/templates/app/ci-workflows/github/workflows/ci.yml +9 -3
  25. package/templates/app/ci-workflows/github/workflows/e2e.yml +1 -0
  26. package/templates/app/ci-workflows/github/workflows/images.yml +1 -0
  27. package/templates/app/ci-workflows/install-steps.yml +2 -0
  28. package/templates/app/ci-workflows/jest-cache.yml +8 -0
  29. package/templates/app/ci-workflows/lint-base.yml +16 -0
  30. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +10 -4
  31. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +1 -0
  32. package/templates/app/ci-workflows-lite/github/workflows/images.yml +1 -0
  33. package/templates/app/gitignore +2 -0
  34. package/templates/app/gitignore-lite +2 -0
  35. package/templates/app/skeleton/.github/dependabot.yml +1 -2
  36. package/templates/app/skeleton/.starcistacks/application-stacks.yaml +1 -0
  37. package/templates/app/skeleton-lite/.github/dependabot.yml +1 -2
  38. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +1 -0
  39. package/templates/app/upgrade-full/.starcistacks/application-stacks.yaml +1 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.6.0 - 2026-10-18
4
+ - Added: `services.codecov|sonar.badgeToken` README badge tokens and `ci.github.visibility` with a private-repository notice (sync, ci verify); `ci.vars` `ownerValue: true` (var sync skips, var set reads stdin, verify checks the name).
5
+ - Fixed: the app release gate counts tag-push CI runs reachable from origin/main, main or HEAD; the secret scans exempt only declared badge tokens.
6
+ - Fixed: empty placeholder lines vanish so every managed file is prettier-stable; CI coverage proof heap, per-side Sonar lint reports merged for the import, per-fe-app build env; `app lint --side` combines with `--sonar`.
7
+
8
+ ## 4.5.1 - 2026-10-18
9
+ - Fixed: managed CI base refs, public build values, caching and timeouts; optional quality service handling, README formatting and Supabase migration-history parsing.
10
+
3
11
  ## 4.5.0 - 2026-10-17
4
12
  - Added: consumer-class CI declarations, `app ci` custody verbs and R296-R298; manifest-driven quality files and split cached CI with immutable action pins.
5
13
  - Added: release audit/local Sonar and main-CI protection; encrypted outside-repository DB dump and armed linked reset with backup/auth/storage validation.
package/ci/cli.mjs CHANGED
@@ -5,7 +5,7 @@ import { readCiDeclaration, sealedKeyNames } from '../runtime/scripts/hfs/ci-dec
5
5
  import { github, refuse } from './github.mjs';
6
6
  import { setSecret } from './secret.mjs';
7
7
  import { setVariable, syncVariables } from './vars.mjs';
8
- import { verifyCi } from './verify.mjs';
8
+ import { badgeVisibilityNotices, verifyCi } from './verify.mjs';
9
9
 
10
10
  const COMMANDS = {
11
11
  'secret set': ['env', 'dependabot', 'repo'],
@@ -46,16 +46,18 @@ function parse(argv) {
46
46
  export async function ciMain(argv, { cwd = process.cwd(), stdout = (s) => process.stdout.write(s), stderr = (s) => process.stderr.write(s), stdin, env = process.env, tools = {} } = {}) {
47
47
  try {
48
48
  const parsed = parse(argv);
49
- let declaration, sealed;
49
+ let doc, declaration, sealed;
50
50
  try {
51
- declaration = readCiDeclaration(parseYaml(fs.readFileSync(path.join(cwd, '.starcistacks/application-stacks.yaml'), 'utf8')));
51
+ doc = parseYaml(fs.readFileSync(path.join(cwd, '.starcistacks/application-stacks.yaml'), 'utf8'));
52
+ declaration = readCiDeclaration(doc);
52
53
  sealed = sealedKeyNames(cwd, { fs });
53
54
  } catch { throw refuse('CI_DECLARATION_INVALID', 'the manifest or sealed key names could not be read'); }
54
55
  if (declaration.problems.length) throw refuse('CI_DECLARATION_INVALID', 'correct the CI declaration before using GitHub');
55
56
  const context = { ...parsed, declaration, sealed, gh: github({ cwd, env, spawn: tools.spawn }), stdin, stdout };
56
57
  if (parsed.command === 'secret set') return await setSecret(context);
57
- if (parsed.command === 'var set') return setVariable(context);
58
+ if (parsed.command === 'var set') return await setVariable(context);
58
59
  if (parsed.command === 'var sync') return syncVariables(context);
60
+ context.notices = badgeVisibilityNotices({ doc, gh: context.gh, repo: context.gh.repository(parsed.options.repo) });
59
61
  return verifyCi(context);
60
62
  } catch (error) {
61
63
  stderr(`${error.code ? error.message : 'CI_USAGE: CI command could not complete'}\n`);
package/ci/vars.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import fs from 'node:fs';
1
2
  import { refuse, scopeArgs, scopeName } from './github.mjs';
2
3
  import { declaredEntry } from './secret.mjs';
3
4
 
@@ -15,18 +16,34 @@ function writeVariable(gh, repo, entry, value, options, stdout) {
15
16
  stdout(`set variable ${entry.name} (${scopeName(options)})\n`);
16
17
  }
17
18
 
18
- export function setVariable({ declaration, gh, options, name, stdout }) {
19
- const entry = declaredEntry(declaration.vars, name, options), value = valueOf(entry, options.value);
19
+ /** Owner-supplied input is read once from stdin, with one trailing newline removed and no value echoed. */
20
+ async function ownerValueOf(options, stdin) {
21
+ if (Object.hasOwn(options, 'value')) throw refuse('CI_VALUE_ARGV', 'an owner-supplied variable takes its value on stdin');
22
+ let value;
23
+ if (stdin === undefined) {
24
+ if (process.stdin.isTTY) throw refuse('CI_VALUE_EMPTY', 'an owner-supplied variable value is required on stdin');
25
+ value = fs.readFileSync(0, 'utf8');
26
+ } else value = typeof stdin === 'function' ? await stdin() : stdin;
27
+ value = String(value ?? '').replace(/\r?\n$/, '');
28
+ if (!value.length) throw refuse('CI_VALUE_EMPTY', 'an owner-supplied variable value is required on stdin');
29
+ return value;
30
+ }
31
+
32
+ /** Owner-supplied variables accept stdin only; other public variables retain manifest and argv agreement. */
33
+ export async function setVariable({ declaration, gh, options, name, stdin, stdout }) {
34
+ const entry = declaredEntry(declaration.vars, name, options);
35
+ const value = entry.ownerValue ? await ownerValueOf(options, stdin) : valueOf(entry, options.value);
20
36
  writeVariable(gh, gh.repository(options.repo), entry, value, options, stdout);
21
37
  return 0;
22
38
  }
23
39
 
24
40
  /** Sync validates the whole plan first and performs no GitHub access until execution is requested. */
25
41
  export function syncVariables({ declaration, gh, options, stdout }) {
26
- const plan = declaration.vars.map((entry) => ({ entry, value: valueOf(entry), options: { env: entry.scope === 'environment' ? entry.environment : undefined } }));
27
- const repo = options.execute && plan.length ? gh.repository(options.repo) : undefined;
42
+ const plan = declaration.vars.map((entry) => ({ entry, value: entry.ownerValue ? undefined : valueOf(entry), options: { env: entry.scope === 'environment' ? entry.environment : undefined } }));
43
+ const repo = options.execute && plan.some((item) => !item.entry.ownerValue) ? gh.repository(options.repo) : undefined;
28
44
  for (const item of plan) {
29
- if (options.execute) writeVariable(gh, repo, item.entry, item.value, item.options, stdout);
45
+ if (item.entry.ownerValue) stdout(`skip variable ${item.entry.name} (${scopeName(item.options)}): owner-supplied value; the owner runs starci app ci var set ${item.entry.name} [--env ENV] with the value on stdin\n`);
46
+ else if (options.execute) writeVariable(gh, repo, item.entry, item.value, item.options, stdout);
30
47
  else stdout(`would set variable ${item.entry.name} (${scopeName(item.options)})\n`);
31
48
  }
32
49
  return 0;
package/ci/verify.mjs CHANGED
@@ -1,7 +1,8 @@
1
+ import { badgeTokenNotices } from '../sync/readme-badges.mjs';
1
2
  import { refuse, scopeArgs, scopeName } from './github.mjs';
2
3
 
3
4
  /** Verification reports names and scopes only; values are compared in memory and excluded from findings. */
4
- export function verifyCi({ declaration, sealed, gh, options, stdout }) {
5
+ export function verifyCi({ declaration, sealed, gh, options, stdout, notices = [] }) {
5
6
  const repo = gh.repository(options.repo), stores = [];
6
7
  const environments = [...new Set([...declaration.secrets, ...declaration.vars].filter((item) => item.scope === 'environment').map((item) => item.environment))].sort();
7
8
  for (const scope of [{}, ...environments.map((env) => ({ env })), { dependabot: true }]) {
@@ -29,8 +30,21 @@ export function verifyCi({ declaration, sealed, gh, options, stdout }) {
29
30
  add(sameKind.some((entry) => entry.name === row.name) ? 'CI_SCOPE_MISMATCH' : store.kind === 'secret' ? 'CI_SECRET_UNDECLARED' : 'CI_VAR_UNDECLARED', row.name, store.scope);
30
31
  }
31
32
  }
32
- if (options.json) stdout(`${JSON.stringify({ ok: findings.length === 0, findings }, null, 2)}\n`);
33
+ if (options.json) stdout(`${JSON.stringify({ ok: findings.length === 0, findings, notices }, null, 2)}\n`);
33
34
  else if (!findings.length) stdout('CI verification passed\n');
34
35
  else for (const finding of findings) stdout(`${finding.code}: ${finding.name} (${finding.scope})\n`);
36
+ if (!options.json) for (const notice of notices) stdout(`${notice}\n`);
35
37
  return findings.length ? 1 : 0;
36
38
  }
39
+
40
+ /** Manifest visibility wins; optional GitHub visibility discovery never changes verification findings or errors. */
41
+ export function badgeVisibilityNotices({ doc, gh, repo }) {
42
+ let visibility = doc?.ci?.github?.visibility;
43
+ if (visibility === undefined) {
44
+ try {
45
+ const remote = gh.json(['repo', 'view', '--json', 'visibility', '--repo', repo]);
46
+ visibility = ['PRIVATE', 'INTERNAL'].includes(remote?.visibility) ? 'private' : 'public';
47
+ } catch { return []; }
48
+ }
49
+ return badgeTokenNotices({ ...doc, ci: { ...doc?.ci, github: { ...doc?.ci?.github, visibility } } }, { repository: `https://github.com/${repo}` });
50
+ }
package/lint/run.mjs CHANGED
@@ -26,7 +26,7 @@ import { STYLE_GLOB } from '../sync/index.mjs';
26
26
  import { byCodeUnit } from '../report/order.mjs';
27
27
  import { braceVariants, globExpression } from '../runtime/scripts/lib/glob.mjs';
28
28
 
29
- // --side judges only that side; root HFS findings belong to be, and Sonar always requires the full lint.
29
+ // --side judges only that side; root HFS findings belong to be, its Sonar report stays on that side.
30
30
  export const LINT_SCHEMA = 'starci/lint@1';
31
31
  /** The side whose stylesheets stylelint judges. */
32
32
  const STYLE_SIDE = 'fe';
@@ -42,11 +42,10 @@ export function workspaceOf(workspace) {
42
42
  return rel;
43
43
  }
44
44
 
45
- /** Side scopes partition the full lint; Sonar requires the complete report and a frontend workspace cannot belong to be. */
45
+ /** Side scopes partition the full lint; Sonar reports share that scope and a frontend workspace cannot belong to be. */
46
46
  function validateSide(opts) {
47
47
  if (opts.side === undefined) return;
48
48
  if (!SIDES.includes(opts.side)) throw new Error('LINT_SIDE_INVALID: --side is be or fe');
49
- if (opts.sonar !== undefined) throw new Error('LINT_SIDE_SONAR_CONFLICT: --side and --sonar do not combine');
50
49
  if (opts.side === 'be' && opts.workspace !== undefined) throw new Error('LINT_SIDE_WORKSPACE_CONFLICT: --side be cannot judge a frontend workspace');
51
50
  }
52
51
 
@@ -312,7 +311,7 @@ export async function lintRepository({ repoRoot, opts, hfsCheck, trackedFiles =
312
311
 
313
312
  let properties = '';
314
313
  try { properties = fs.readFileSync(path.join(repoRoot, 'sonar-project.properties'), 'utf8'); } catch { /* no properties: every finding keeps its own path */ }
315
- const sourceRoots = sourceRootsOf(properties);
314
+ const sourceRoots = sourceRootsOf(properties).filter((root) => findingOnSide({ path: root }, opts.side));
316
315
  const tracked = sourceRoots.length ? trackedFiles(repoRoot) : [];
317
316
  const sonar = mergeReports([
318
317
  sonarReport(kept, { sourceRoots, tracked }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/hfs",
3
- "version": "4.5.0",
3
+ "version": "4.6.0",
4
4
  "description": "The StarCi app command implementation for scaffolding, linting, checking and synchronizing one product repository.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -20,29 +20,61 @@ function runsOf(result) {
20
20
  } catch { return null; }
21
21
  }
22
22
 
23
+ /** Resolve each available default-branch reference once, including HEAD in detached release worktrees. */
24
+ function referenceTips(spawn, cwd, defaultBranch) {
25
+ const refs = [`origin/${defaultBranch}`, defaultBranch, 'HEAD'];
26
+ return refs.flatMap((name) => {
27
+ const result = run(spawn, cwd, 'git', ['rev-parse', '--verify', '--quiet', `${name}^{commit}`]);
28
+ const tip = String(result.stdout ?? '').trim();
29
+ return !result.error && result.status === 0 && SHA.test(tip) ? [{ name, tip }] : [];
30
+ });
31
+ }
32
+
33
+ /** Equality proves reachability directly; ancestry is unknown only when every available probe errors. */
34
+ function pushAncestry(spawn, cwd, row, refs) {
35
+ if (refs.some((ref) => ref.tip === row.headSha)) return { candidate: true, unknown: false };
36
+ let known = false;
37
+ for (const ref of refs) {
38
+ const result = run(spawn, cwd, 'git', ['merge-base', '--is-ancestor', row.headSha, ref.tip]);
39
+ if (!result.error && result.status === 0) return { candidate: true, unknown: false };
40
+ if (!result.error && result.status === 1) known = true;
41
+ }
42
+ return { candidate: false, unknown: !known };
43
+ }
44
+
45
+ /** Branch rows remain candidates; push rows must reach a resolved reference, and each databaseId counts once. */
46
+ function candidateRuns(spawn, cwd, branch, pushes, refs) {
47
+ const candidates = branch.filter((row) => row && SHA.test(row.headSha));
48
+ let ancestryUnknown = refs.length === 0;
49
+ for (const row of pushes) {
50
+ if (!row || !SHA.test(row.headSha) || row.event !== 'push') continue;
51
+ const ancestry = pushAncestry(spawn, cwd, row, refs);
52
+ if (ancestry.candidate) candidates.push(row);
53
+ if (ancestry.unknown) ancestryUnknown = true;
54
+ }
55
+ const unique = new Map();
56
+ for (const row of candidates) if (!unique.has(row.databaseId)) unique.set(row.databaseId, row);
57
+ return { candidates: [...unique.values()], ancestryUnknown };
58
+ }
59
+
23
60
  /** Reads branch runs plus push/tag runs whose commits are reachable from the default branch; the newest completed run decides, even if a newer run is pending. */
24
61
  export function latestDefaultBranchCi({ spawn, cwd, defaultBranch = 'main' }) {
62
+ const refs = referenceTips(spawn, cwd, defaultBranch);
63
+ const unknown = (why) => ({ state: 'unknown', why: `${why} (refs: ${refs.map((ref) => ref.name).join(', ') || 'none'})` });
25
64
  const args = ['run', 'list', '--workflow', 'ci.yml'];
26
65
  const tail = ['--limit', '20', '--json', FIELDS];
27
66
  const branch = runsOf(run(spawn, cwd, 'gh', [...args, '--branch', defaultBranch, ...tail]));
28
- if (!branch) return { state: 'unknown', why: 'gh branch runs unavailable (missing gh, authentication, or unreadable response)' };
67
+ if (!branch) return unknown('gh branch runs unavailable (missing gh, authentication, or unreadable response)');
29
68
  const pushes = runsOf(run(spawn, cwd, 'gh', [...args, '--event', 'push', ...tail]));
30
- if (!pushes) return { state: 'unknown', why: 'gh push runs unavailable (missing gh, authentication, or unreadable response)' };
31
- const candidates = branch.filter((row) => row && SHA.test(row.headSha));
32
- let ancestryUnknown = false;
33
- for (const row of pushes) {
34
- if (!row || !SHA.test(row.headSha) || row.event !== 'push') continue;
35
- const ancestor = run(spawn, cwd, 'git', ['merge-base', '--is-ancestor', row.headSha, defaultBranch]);
36
- if (!ancestor.error && ancestor.status === 0) candidates.push(row);
37
- else if (ancestor.error || ancestor.status !== 1) ancestryUnknown = true;
38
- }
69
+ if (!pushes) return unknown('gh push runs unavailable (missing gh, authentication, or unreadable response)');
70
+ const { candidates, ancestryUnknown } = candidateRuns(spawn, cwd, branch, pushes, refs);
39
71
  const completed = candidates.filter((row) => row.status === 'completed' && Number.isFinite(Date.parse(row.createdAt)));
40
72
  completed.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt) || Number(b.databaseId) - Number(a.databaseId));
41
73
  const latest = completed[0];
42
- if (!latest) return { state: 'unknown', why: ancestryUnknown ? 'cannot establish push run ancestry on the default branch' : 'no completed CI run on the default branch' };
74
+ if (!latest) return unknown(ancestryUnknown ? 'cannot establish push run ancestry on the default branch' : 'no completed CI run on the default branch');
43
75
  if (latest.conclusion === 'success') return { state: 'green', run: latest };
44
76
  if (RED.has(latest.conclusion)) return { state: 'red', run: latest };
45
- return { state: 'unknown', why: 'latest completed CI run has no success or red conclusion', run: latest };
77
+ return { ...unknown('latest completed CI run has no success or red conclusion'), run: latest };
46
78
  }
47
79
 
48
80
  /** Only a complete starci/ci-override@1 record for this exact commit can excuse a red run. */
package/release/gate.mjs CHANGED
@@ -1,4 +1,4 @@
1
- // gate.mjs - `starci app release gate`: the honest release gate. It runs the app's own gates on a clean HEAD and ONLY when every one is green it
1
+ // gate.mjs - `starci app release gate`: the honest release gate. It runs the app's own gates on a clean HEAD and when all required gates pass it
2
2
  // writes the L4 record <git-common-dir>/starci-release/<HEAD sha>.l4.json (head, tag, per-gate log path and sha256) and creates the annotated tag.
3
3
  // The canon pre-push hook lets main and v* tags through only for a HEAD with that tag and that record. Nothing is pushed here (see push.mjs).
4
4
  // The gate list comes from the optional `release:` block of .starcistacks/application-stacks.yaml, else from the app's managed package scripts.
@@ -13,6 +13,7 @@ import { failure, refusal } from '../stack/errors.mjs';
13
13
  import { latestDefaultBranchCi, requireGreenCi } from './ci-red.mjs';
14
14
 
15
15
  export const RELEASE_CODE = 'RELEASE_CONFIG_INVALID';
16
+ const SONAR_UNREACHABLE = 'SONAR_UNREACHABLE';
16
17
 
17
18
  const FIELDS = new Set(['gates', 'tagPrefix', 'defaultBranch', 'tagMatchesVersion']);
18
19
  const GATE_FIELDS = new Set(['name', 'command', 'summary', 'env']);
@@ -63,14 +64,15 @@ function auditGate(audit) {
63
64
 
64
65
  /** A requested local Sonar scan uses only the declared host and token variable; the token never enters argv. */
65
66
  function sonarGate(services, env) {
66
- const sonar = services?.sonar;
67
+ const configured = services?.sonar;
68
+ const sonar = configured?.provider === 'sonarcloud' ? { ...configured, host: 'https://sonarcloud.io' } : configured;
67
69
  const tokenName = sonar?.token;
68
70
  if (!sonar?.host || !ENV_KEY.test(tokenName) || !env[tokenName]) throw refusal('RELEASE_SONAR_UNAVAILABLE', 'declare an enabled services.sonar host and set its ci.token environment variable');
69
71
  const command = ['npx', '--yes', '@sonar/scan@4.3.0', `-Dsonar.host.url=${sonar.host}`];
70
72
  const organization = sonar.organization ?? (sonar.organizationVar ? env[sonar.organizationVar] : null);
71
73
  if (organization) command.push(`-Dsonar.organization=${organization}`);
72
74
  if (!command.every((word) => TOKEN.test(word))) throw refusal('RELEASE_SONAR_UNAVAILABLE', 'Sonar host and organization must be plain command arguments');
73
- return { name: 'sonar', command, summary: null, env: { SONAR_TOKEN: env[tokenName] } };
75
+ return { name: 'sonar', command, summary: null, env: { SONAR_TOKEN: env[tokenName] }, provider: sonar };
74
76
  }
75
77
 
76
78
  function gateProblems(entry, at, seen) {
@@ -209,6 +211,32 @@ export function launch(argv, platform) {
209
211
  return [argv[0], argv.slice(1)];
210
212
  }
211
213
 
214
+ /** Sonar availability is public and bounded; HTTP uses the existing injected fetch seam. */
215
+ async function sonarAvailability(ctx, gate) {
216
+ const sonar = gate.provider;
217
+ if (!sonar) return null;
218
+ let up = false;
219
+ try {
220
+ const response = await ctx.fetch(sonar.host.replace(/\/$/u, '') + '/api/system/status', { signal: AbortSignal.timeout(10000) });
221
+ up = response.ok && (await response.json()).status === 'UP';
222
+ } catch { up = false; }
223
+ if (up) return null;
224
+ const detail = sonar.optional
225
+ ? `notice: sonar skipped: ${sonar.host} not reachable (services.sonar.optional)`
226
+ : `${SONAR_UNREACHABLE}: ${sonar.host} did not report UP within ten seconds`;
227
+ return { skipped: sonar.optional === true, pass: false, detail };
228
+ }
229
+
230
+ /** Unreachable optional providers remain explicit skipped rows, with a real log and no scan invocation. */
231
+ async function runAvailableGate(ctx, gate, options) {
232
+ const availability = await sonarAvailability(ctx, gate);
233
+ if (!availability) return runGate(ctx, gate, options);
234
+ const log = path.join(options.dir, `${gate.name}.log`);
235
+ fs.writeFileSync(log, availability.detail + '\n');
236
+ return { name: gate.name, command: gate.command.join(' '), ...availability, seconds: 0,
237
+ summary: null, log: path.resolve(log), logSha256: sha256(log), env: Object.keys(gate.env) };
238
+ }
239
+
212
240
  function runGate(ctx, gate, { dir, platform }) {
213
241
  const log = path.join(dir, `${gate.name}.log`);
214
242
  const started = ctx.now();
@@ -270,7 +298,7 @@ function inspect(ctx, config, options) {
270
298
  }
271
299
 
272
300
  function planLines(config, facts, dir, recordPath) {
273
- const out = [`L4 record ${recordPath}`, `logs ${dir}`, '', 'gates (all must pass, in this order):'];
301
+ const out = [`L4 record ${recordPath}`, `logs ${dir}`, '', 'gates (required gates must pass, optional provider outages are skipped, in this order):'];
274
302
  for (const gate of config.gates) {
275
303
  const summary = gate.summary ? ` (summary: ${gate.summary})` : '';
276
304
  out.push(` ${gate.name.padEnd(17)} ${gate.command.join(' ')}${summary}`);
@@ -289,28 +317,31 @@ function assertUnmoved(ctx, head) {
289
317
  function writeRecord(ctx, { head, tag, results, recordPath }) {
290
318
  const record = {
291
319
  schema: 'starci-app-l4-record/1', head, tag, createdAt: new Date(ctx.now()).toISOString(), producedBy: 'starci app release gate',
292
- note: 'The full gates ran on a clean HEAD; every entry links the real log and its sha256.',
320
+ note: 'Required gates passed on a clean HEAD; optional provider skips are explicit and every entry links its log and sha256.',
293
321
  gates: results,
294
322
  };
295
323
  fs.mkdirSync(path.dirname(recordPath), { recursive: true });
296
324
  fs.writeFileSync(recordPath, `${JSON.stringify(record, null, 2)}\n`);
297
325
  }
298
326
 
299
- function runAll(ctx, config, { dir, platform }) {
300
- fs.mkdirSync(dir, { recursive: true });
301
- const results = [];
302
- for (const gate of config.gates) {
303
- const result = runGate(ctx, gate, { dir, platform });
304
- results.push(result);
305
- if (!result.pass) break;
306
- }
307
- return results;
327
+ /** Gates run sequentially because each failure prevents subsequent commands and record creation. */
328
+ async function runAll(ctx, config, options) {
329
+ fs.mkdirSync(options.dir, { recursive: true });
330
+ return config.gates.reduce(async (pending, gate) => {
331
+ const results = await pending;
332
+ if (results.some((result) => !result.pass && !result.skipped)) return results;
333
+ return [...results, await runAvailableGate(ctx, gate, options)];
334
+ }, Promise.resolve([]));
308
335
  }
309
336
 
310
- const summaryOf = (result) => `${result.pass ? 'PASS' : 'FAIL'} ${result.name} (${result.detail}, ${result.seconds}s) log ${result.log}`;
337
+ function summaryOf(result) {
338
+ let status = result.pass ? 'PASS' : 'FAIL';
339
+ if (result.skipped) status = 'SKIPPED';
340
+ return `${status} ${result.name} (${result.detail}, ${result.seconds}s) log ${result.log}`;
341
+ }
311
342
 
312
343
  /**
313
- * `starci app release gate`: dry run prints the preconditions, the gate list, the head and the tag; --execute runs every gate and, only if all are green,
344
+ * `starci app release gate`: dry run prints the preconditions, the gate list, the head and the tag; --execute runs every gate and, only if required gates pass,
314
345
  * writes the L4 record and creates the annotated tag. options: { execute, sonar, source, tagPrefix, platform }.
315
346
  */
316
347
  export async function releaseGate(ctx, config, options = {}) {
@@ -325,14 +356,14 @@ export async function releaseGate(ctx, config, options = {}) {
325
356
  const base = { head: facts.head, tag: facts.chosen.tag, record: recordPath, gates: config.gates.map((gate) => gate.name), ci: { state: ci.state, notices: ciLines } };
326
357
  if (facts.red.length > 0) return { ok: false, lines: [...plan, ...facts.red.map((item) => `RED: ${item}`), 'nothing was run.'], data: { ...base, red: facts.red, executed: false } };
327
358
  if (options.execute !== true) return { ok: true, lines: [...plan, '', 'dry run: no gate was run, nothing written. preconditions OK.'], data: { ...base, red: [], executed: false } };
328
- const results = runAll(ctx, config, { dir, platform: options.platform ?? process.platform });
359
+ const results = await runAll(ctx, config, { dir, platform: options.platform ?? process.platform });
329
360
  const report = results.map(summaryOf);
330
- const failed = results.filter((result) => !result.pass);
361
+ const failed = results.filter((result) => !result.pass && !result.skipped);
331
362
  if (failed.length > 0) return { ok: false, lines: [...plan, ...report, `gate(s) red: ${failed.map((item) => item.name).join(', ')}. No L4 record, no tag.`], data: { ...base, executed: true, results } };
332
363
  assertUnmoved(ctx, facts.head);
333
364
  writeRecord(ctx, { head: facts.head, tag: facts.chosen.tag, results, recordPath });
334
365
  const created = facts.chosen.state !== 'on-head';
335
- if (created) git(ctx, ['tag', '-a', facts.chosen.tag, '-m', `release ${facts.chosen.tag}: gates green on ${facts.head.slice(0, 12)} (L4 record ${path.basename(recordPath)})`, facts.head]);
366
+ if (created) git(ctx, ['tag', '-a', facts.chosen.tag, '-m', `release ${facts.chosen.tag}: required gates passed on ${facts.head.slice(0, 12)} (L4 record ${path.basename(recordPath)})`, facts.head]);
336
367
  const tagged = created ? `annotated tag ${facts.chosen.tag} created on ${facts.head.slice(0, 10)}` : `annotated tag ${facts.chosen.tag} already on ${facts.head.slice(0, 10)}`;
337
368
  return { ok: true, lines: [...plan, ...report, `L4 record written: ${recordPath}`, tagged], data: { ...base, executed: true, results, tagCreated: created } };
338
369
  }
package/release/push.mjs CHANGED
@@ -14,7 +14,7 @@ function readRecord(file) {
14
14
  }
15
15
 
16
16
  const greenRecord = (record, head, tag) => record?.head === head && record?.tag === tag
17
- && Array.isArray(record.gates) && record.gates.length > 0 && record.gates.every((gate) => gate?.pass === true);
17
+ && Array.isArray(record.gates) && record.gates.length > 0 && record.gates.every((gate) => gate?.pass === true || (gate?.name === 'sonar' && gate?.skipped === true));
18
18
 
19
19
  /** The release gate as the hook reads it: the annotated tag on HEAD whose L4 record names head and tag. Returns { tag } or { reason }. */
20
20
  function gateState(ctx, head, prefix) {
@@ -29,14 +29,14 @@ pins:
29
29
  source: packages/grammar/package.json
30
30
  why: 'the grammar front ends read: the brand layer sets `--font-sans` and `--font-mono` and the grammar reads them; it carries the Input tel kind, the copyable and read-only Input, the IconButton disclosure props and count badge, ChatBubble, FillViewport, Textarea mentions and Enter-to-send, the leading PrimaryRailLayout rail, the plate-less Image, ListRow, the soft Alert, Progress steps, the floating primary IconButton, FlowGraph, the STATE-8 loading views and the bordered selectable TagGroup chips. The runtime source version wins for a @starci package.'
31
31
  '@starci/eslint-canon-be':
32
- version: 3.4.2
32
+ version: 3.4.4
33
33
  group: starci
34
34
  install: registry
35
35
  side: be
36
36
  source: packages/eslint/be/package.json
37
37
  why: 'the back-end ESLint canon: `loadHfs(import.meta.url)` of be/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the be side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; `starciBeConfig({ hfs: loadHfs(import.meta.url) })` is the typed factory and the BE-CONVENTION laws are its rules.'
38
38
  '@starci/eslint-canon-fe':
39
- version: 8.4.2
39
+ version: 8.4.4
40
40
  group: starci
41
41
  install: registry
42
42
  side: fe
@@ -62,7 +62,7 @@ pins:
62
62
  side: both
63
63
  source: packages/prettier-config/package.json
64
64
  '@starci/jest-preset':
65
- version: 2.3.0
65
+ version: 2.3.1
66
66
  group: starci
67
67
  install: registry
68
68
  side: be
@@ -76,14 +76,14 @@ pins:
76
76
  source: packages/test-world/package.json
77
77
  why: 'the shared e2e library of every back end (R47, R48), a devDependency of every back end: the warm stack behind toxiproxy, the network-edge fakes, the Nest boot, the typed useTestWorld handle, useSandbox for contract specs and the outage lock. Kafka is real own infra (the one digest-pinned apache/kafka KRaft image, a broker listener and proxy per slot, slot-prefixed topics, consumer groups and client ids) and Postgres is schema-per-context (a schema and a login role per context in a shared database). Each worker has its own data slot (<ns>_w<k>) with its own databases, Keycloak realm, leased Redis DB, bucket/collection/topic prefixes, fakes host, toxiproxy proxies and outage lock; a run provisions min(jest workers, workers cap, default 2) slots, and state file protocol 2 pairs exactly with @starci/jest-preset (TEST_WORLD_PAIR_MISMATCH otherwise). A modules world boots real peer apps beside its modules, world.apps.<name>.during(fn) is the outage of a peer app, w.keycloak.clientSecret(client) answers the run-generated secret of a confidential realm client, world.keycloak.events/sessions read the realm user events and live sessions, world.infra.postgresql.connection(name) takes one database connection down while the others serve, and world.resolve and scope.resolve take any Nest token. Every path a declaration names (`stack`, seeds, the realm, Dockerfiles) resolves from the app root (the directory of hfs.json); the globalSetup registers the path aliases as TypeScript resolves them. Its CLI main is exported at ./cli for starci app stack.'
78
78
  '@starci/cli':
79
- version: 1.5.0
79
+ version: 1.6.0
80
80
  group: starci
81
81
  install: registry
82
82
  side: both
83
83
  source: packages/cli/package.json
84
84
  why: 'the only StarCi command package; product repositories install it for starci app, while @starci/hfs remains its pinned transitive implementation.'
85
85
  '@starci/hfs':
86
- version: 4.5.0
86
+ version: 4.6.0
87
87
  group: starci
88
88
  install: registry
89
89
  side: both
@@ -41,8 +41,8 @@ newCode:
41
41
  minReviewedPercent: 100
42
42
  unreviewedMax: 0
43
43
  # The whole code, not only the new code. Every finding of the HFS canon reaches Sonar through ONE entry, `starci app lint`
44
- # (packages/hfs, contract changes hfs-sonar-import and hfs-lint-entry), which writes ONE file, reports/lint.sonar.json
45
- # (`starci app lint --sonar reports/lint.sonar.json`), named by sonar.externalIssuesReportPaths; the gate holds the open-issue count of
44
+ # (packages/hfs, contract changes hfs-sonar-import and hfs-lint-entry). CI runs it per side with --side and --sonar,
45
+ # then merges rules by id and joins issues into ONE file, reports/lint.sonar.json, named by sonar.externalIssuesReportPaths; the gate holds the open-issue count of
46
46
  # the whole project at zero. The file carries three engines:
47
47
  # starci-hfs the repository findings of `starci app check` (managed files, tree, pins, CI, contracts; the rule id is the finding code)
48
48
  # eslint ESLint's json over the repository (the BE and FE canon plugins alike, per-file and project-graph rules)
@@ -149,6 +149,16 @@ $defs:
149
149
  target: {type: number, minimum: 0, maximum: 100}
150
150
  components: {enum: [auto]}
151
151
  required: {type: boolean}
152
+ optional:
153
+ description: Skip an unreachable quality provider with a notice; SonarQube defaults true, SonarCloud and Codecov false.
154
+ type: boolean
155
+ badgeToken:
156
+ description: >-
157
+ codecov and sonar only: a read-only, public-safe badge token (Codecov graphing token, Sonar project badge token)
158
+ that the README badge of a private repository needs. It is public configuration rendered into README.md, never a
159
+ secret; a CI token or any write credential never goes here.
160
+ type: string
161
+ pattern: '^[A-Za-z0-9_-]{6,128}$'
152
162
  ecosystems: {type: array, items: {enum: [npm, docker, github-actions]}}
153
163
  schedule:
154
164
  type: object
@@ -272,6 +282,9 @@ $defs:
272
282
  repository: {type: string, pattern: '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$'}
273
283
  defaultBranch: {type: string, minLength: 1, default: main}
274
284
  workflow: {type: string, pattern: '^[A-Za-z0-9_.-]+\.ya?ml$', default: ci.yml}
285
+ visibility:
286
+ description: The GitHub repository visibility; private READMEs need services.<id>.badgeToken for live badges. Omitted means unknown.
287
+ enum: [public, private]
275
288
  secrets: {type: array, items: {$ref: '#/$defs/ciSecret'}}
276
289
  vars: {type: array, items: {$ref: '#/$defs/ciVariable'}}
277
290
  dependabot:
@@ -307,6 +320,9 @@ $defs:
307
320
  purpose: {type: string}
308
321
  optional: {type: boolean}
309
322
  ciVariable:
323
+ description: >-
324
+ A public CI variable either carries its literal value, or declares ownerValue true (no value) when only the owner
325
+ may supply it; var sync skips an owner-supplied value and ci verify checks that it exists by name.
310
326
  anyOf:
311
327
  - type: object
312
328
  additionalProperties: false
@@ -327,6 +343,25 @@ $defs:
327
343
  value: {type: string}
328
344
  purpose: {type: string}
329
345
  optional: {type: boolean}
346
+ - type: object
347
+ additionalProperties: false
348
+ required: [name, scope, ownerValue]
349
+ properties:
350
+ name: {$ref: '#/$defs/ciName'}
351
+ scope: {const: repository}
352
+ ownerValue: {const: true}
353
+ purpose: {type: string}
354
+ optional: {type: boolean}
355
+ - type: object
356
+ additionalProperties: false
357
+ required: [name, scope, ownerValue, environment]
358
+ properties:
359
+ name: {$ref: '#/$defs/ciName'}
360
+ scope: {const: environment}
361
+ environment: {type: string, pattern: '^[a-z][a-z0-9-]*$'}
362
+ ownerValue: {const: true}
363
+ purpose: {type: string}
364
+ optional: {type: boolean}
330
365
  serviceStack:
331
366
  description: >-
332
367
  Which stack defines a local service - either a project-owned .starcistacks stack
@@ -1,7 +1,7 @@
1
1
  // GENERATED by scripts/cli/gen-catalog.mjs — do not edit
2
2
  export const CATALOG = {
3
3
  "schema": "starci/cli-catalog@1",
4
- "hash": "d230745d3aa5cfe65f75cb5fb2872924b935f1b4610c0b72db2f729572386404",
4
+ "hash": "93fd9007c93fe7430043d8b6682fd46dd0e1d5f9f9b7094fc94ffad2ccdcceeb",
5
5
  "global": [
6
6
  {
7
7
  "name": "json",
@@ -224,12 +224,12 @@ export const CATALOG = {
224
224
  {
225
225
  "name": "value",
226
226
  "type": "string",
227
- "summary": "public variable value that must agree with the manifest"
227
+ "summary": "public variable value that must agree with the manifest; var set ownerValue requires stdin instead"
228
228
  },
229
229
  {
230
230
  "name": "execute",
231
231
  "type": "boolean",
232
- "summary": "apply the variable sync plan"
232
+ "summary": "apply the variable sync plan; var sync skips ownerValue variables"
233
233
  },
234
234
  {
235
235
  "name": "json",
@@ -7317,12 +7317,12 @@ export const CATALOG = {
7317
7317
  {
7318
7318
  "name": "dev",
7319
7319
  "type": "boolean",
7320
- "summary": "save the packages as development dependencies"
7320
+ "summary": "save the CLI and explicit packages as development dependencies"
7321
7321
  },
7322
7322
  {
7323
7323
  "name": "canon",
7324
7324
  "type": "boolean",
7325
- "summary": "install the target CLI and all of its exact StarCi canon pins"
7325
+ "summary": "install the target CLI and move its declared exact StarCi canon pins"
7326
7326
  }
7327
7327
  ],
7328
7328
  "exit": {
@@ -7353,10 +7353,12 @@ export const CATALOG = {
7353
7353
  "bare package names resolve only from knowledge/hfs/canon-pins.yaml and never through a registry lookup",
7354
7354
  "explicit versions use exact x.y.z form and must agree with the canon pin when one exists, except the target CLI version on a canon upgrade",
7355
7355
  "naming @starci/cli or passing --canon installs the exact target CLI first,",
7356
- "then reads its installed HFS runtime canon-pins.yaml and installs every pinned @starci package in the same host lock",
7356
+ "then reads its installed HFS runtime canon-pins.yaml and, in the same host lock,",
7357
+ "moves each other pinned @starci package only where a root or workspace package.json declares it, in its own section",
7357
7358
  "--canon without a CLI positional uses the running runtime's exact CLI pin as the target",
7358
7359
  "target pins never fall back to the running runtime when the target pin file is missing or inconsistent",
7359
- "each target StarCi pin is reported as moved or refused with a reason; a failed canon phase exits nonzero even when the first CLI install succeeded",
7360
+ "each target StarCi pin is reported as moved (with its workspace), skipped when no package.json declares it, or refused with a reason",
7361
+ "a failed canon phase exits nonzero even when the first CLI install succeeded",
7360
7362
  "installs always use --save-exact and report the package.json and package-lock.json diff stat"
7361
7363
  ],
7362
7364
  "runtimeSide": "host"
@@ -4,6 +4,14 @@ import { parseYaml } from '../../engine/yaml.mjs';
4
4
 
5
5
  const nameOf = (item) => typeof item === 'string' ? item : item?.name;
6
6
 
7
+ /** A public CI variable declares either a manifest value or an owner-supplied value, never both. */
8
+ function variableProblems(item, at, invalid) {
9
+ const hasValue = item != null && Object.hasOwn(item, 'value');
10
+ const hasOwnerValue = item != null && Object.hasOwn(item, 'ownerValue');
11
+ if (hasValue && hasOwnerValue) invalid(at, 'a variable has either a value or ownerValue');
12
+ else if (!hasValue && item?.ownerValue !== true) invalid(at, 'a variable needs a value or ownerValue: true');
13
+ }
14
+
7
15
  /** Each CI name belongs to one class and scope; declarations contain names and public configuration only. */
8
16
  export function readCiDeclaration(doc) {
9
17
  const problems = [], seen = new Set();
@@ -17,8 +25,9 @@ export function readCiDeclaration(doc) {
17
25
  if (!['repository', 'environment'].includes(scope)) invalid(`${where}.scope`, 'scope must be repository or environment');
18
26
  if (scope === 'environment' && (typeof item?.environment !== 'string' || !/^[a-z][a-z0-9-]*$/.test(item.environment))) invalid(`${where}.environment`, 'environment scope requires an environment name');
19
27
  if ((dependabot || scope !== 'environment') && item && Object.hasOwn(item, 'environment')) invalid(`${where}.environment`, 'only environment scope may name an environment');
28
+ if (variable) variableProblems(item, where, invalid);
20
29
  return { name, scope, environment: item?.environment ?? null, purpose: item?.purpose ?? null,
21
- optional: item?.optional === true, ...(variable ? { value: item?.value } : {}) };
30
+ optional: item?.optional === true, ...(variable ? { value: item?.value, ownerValue: item?.ownerValue === true } : {}) };
22
31
  });
23
32
  const ci = doc?.ci;
24
33
  const secrets = normalize(ci?.secrets, 'ci.secrets');
@@ -33,13 +42,13 @@ export function qualityServices(doc, { example = false } = {}) {
33
42
  const dependabot = services.dependabot ?? {}, audit = services.audit ?? {};
34
43
  return {
35
44
  sonar: !sonar || sonar.mode === 'disabled' ? null : {
36
- provider: sonar.provider, mode: sonar.mode, host: sonar.host?.public || sonar.host?.local || null,
45
+ provider: sonar.provider, mode: sonar.mode, optional: sonar.optional ?? sonar.provider === 'sonarqube', host: sonar.host?.public || sonar.host?.local || null,
37
46
  organization: sonar.organization ?? null,
38
47
  organizationVar: sonar.ci?.organizationVar ?? (sonar.provider === 'sonarcloud' && (example || list(sonar.runtimeSecrets).length > 0) && !list(sonar.credentials).length ? 'SONAR_ORGANIZATION' : null), projectKey: sonar.projects?.[0]?.key ?? null,
39
48
  qualityGate: sonar.qualityGate ?? (example ? 'starci-quality-lite' : 'starci-quality'), wiring: sonar.ci?.wiring ?? 'required',
40
49
  token: sonar.ci?.token || nameOf(sonar.ci?.secrets?.[0]) || 'SONAR_TOKEN', hostVar: sonar.ci?.hostVar || 'SONAR_HOST_URL',
41
50
  },
42
- codecov: { enabled: codecov.mode !== 'disabled', auth: codecov.auth ?? 'oidc', token: codecov.ci?.token ?? null,
51
+ codecov: { optional: codecov.optional ?? false, enabled: codecov.mode !== 'disabled', auth: codecov.auth ?? 'oidc', token: codecov.ci?.token ?? null,
43
52
  flag: codecov.flag ?? null, target: codecov.target ?? 100, components: codecov.components ?? 'auto', required: codecov.required ?? true, wiring: codecov.ci?.wiring ?? 'required' },
44
53
  dependabot: { ecosystems: [...(dependabot.ecosystems ?? ['npm', 'github-actions', 'docker'])],
45
54
  schedule: { interval: 'weekly', ...dependabot.schedule }, groups: { minorPatch: 'grouped', major: 'per-package', ...dependabot.groups },